feat(web): configurable fonts and sizes under Settings → Appearance - #5103
feat(web): configurable fonts and sizes under Settings → Appearance#5103StiensWout wants to merge 31 commits into
Conversation
Adds a Fonts section to Appearance with four client-persisted preferences — interface (sans), composer, code, and terminal font families. Family only; sizing is deliberately left for a follow-up. - contracts: four FontFamilyPreference client settings (empty string means default), patchable per key - the font theme tokens move out of the inline Tailwind theme so utilities reference the variables, and a FontAppearanceSync applies custom families as CSS variable overrides with the default stacks kept as fallbacks - the composer gets its own --font-composer variable (defaults to the sans stack) applied on the editor surface, so prompt writers can use a mono face without changing the rest of the app - the terminal preference feeds the Ghostty surface's font option and live setFont from pingdotgg#4860's groundwork; the bundled Nerd Font symbols fallback keeps prompt glyphs on any custom face - each settings row shows a live preview rendered in the resolved stack, with per-row reset; custom names are normalized and quoted through tested pure helpers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…views Replaces the free-text family inputs with dropdowns of curated faces filtered through document.fonts.check so only fonts that will actually render are offered (bundled webfonts always appear); a Custom… option keeps arbitrary families possible. Each option renders in its own face, and every row gains a preview card styled like the real surface — chat text, a mini composer, a highlighted code snippet, and a dark terminal prompt — rendered in the resolved stack, matching mobile's Appearance screen style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre and code elements in the preview cards carried the preflight mono font-family, overriding the card's resolved stack, so those two previews never changed with the dropdown. They inherit now; also brace the comment-looking literal flagged by lint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…views document.fonts.check() answers true for families that are not installed (nothing needs loading), so the dropdowns offered fonts that silently fell back to the default stack and the sans-side previews appeared not to update. Availability now comes from canvas metric probing: a family counts as available when substituting it before a generic changes the measured advance. The terminal preview card also follows the app theme now — the real terminal renders on the app background in light mode, so a hardcoded dark card was inconsistent theming. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The base styles hardcoded literal font stacks on body and pre/code, so the runtime --font-sans/--font-mono overrides from Settings -> Appearance only reached the preview cards. Reference the theme tokens instead; the literal stacks stay single-sourced in the @theme block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ApprovabilityVerdict: Needs human review This PR introduces a new user-facing feature (configurable fonts and sizes in Settings → Appearance) with new UI components, settings fields, font detection logic, and terminal auto-sizing. New features warrant human review. You can customize Macroscope's approvability policy. Learn more. |
Client settings hydrate after mount, which dropped two things on reload: the terminal font (setFont fired while the surface was still loading, so the preference never reached the created terminal) and the custom-font input (customMode initialized from the pre-hydration empty value, leaving the field hidden behind a "Custom" trigger). The terminal re-applies the current preference once the surface exists, and the input visibility now derives from the value itself. Also groups mixed dropdowns (composer) into labeled Sans serif / Monospace sections and removes the composer's redundant default entry - the reset affordance already returns it to following the interface font. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…om names Every font dropdown now offers the full catalog split into bold Sans serif / Monospace sections, so any surface can point at any face. The custom input edits a draft that only commits once the typed name probes as an installed font - the current font holds (and the field flags invalid) while a partial or unknown name is typed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… tokens The @pierre/diffs surfaces (file previews, chat diffs, annotatable code, search lines) render inside shadow roots with their own literal font stack as fallback, so the code font preference never reached them. The library consults --diffs-font-family/--diffs-header-font-family as override hooks and custom properties inherit across the shadow boundary, so defining the hooks once at :root covers every consumer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r preview Restore defaults now lists and resets the four font-family preferences (the General page's restore is intentionally global), and the composer preview falls back to the resolved interface stack so it matches the runtime var(--font-composer) chain instead of the bare default sans stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review direction the interface and code fonts stay on the theme defaults; only the prompt textarea (people who prefer writing prompts in a mono face) and the terminal remain configurable. The textarea setting is retitled to make its scope explicit - the rest of the composer follows the interface font. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An emptied custom font field now resets the preference to the default instead of silently keeping the previous font behind an empty-looking input; partial or unknown names still hold the current font while typing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The final split keeps every surface configurable: interface (labels, controls, messages, composer chrome), prompt (textarea and placeholder only), code (code blocks, diffs, file previews), and terminal. Every dropdown now offers the same Default entry, fixing the prompt/terminal inconsistency. The custom input no longer commits on every keystroke: it edits a local draft and commits 400ms after typing pauses (or on Enter/blur), so the app does not reflow mid-word and the unknown-name flag waits for the pause. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Quote custom terminal families before they reach the canvas font string:
a non-ident name ("3270 Nerd Font", "M+ 1m") made the whole string
invalid CSS, so the assignment silently kept the previous font.
- Point the DM Sans catalog entry at the registered "DM Sans Variable"
family; the bundled webfont never registers plain "DM Sans", so the
option rendered in the serif fallback and only applied by coincidence.
- Cap the custom input at the schema's 200-character limit.
- Cover fontOptionCategories and non-ident quoting with tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The custom input now replaces the dropdown instead of stacking beneath it, so entering custom mode no longer grows the row and pushes the rest of the page down; it autofocuses, and Escape discards uncommitted typing without closing the settings page. Typefaces differ in x-height at the same pixel size (DM Sans 0.51 vs JetBrains Mono 0.55), so switching family also changed apparent text size. Each surface now carries a font-size-adjust measured from its own default stack: the platform default renders unchanged, and any chosen font is scaled to match it. Measurements are re-taken once webfonts finish loading, since an early probe describes the fallback face rather than the default. The sidebar PR number follows the interface font (tabular digits keep it from reflowing) instead of the code font. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Selecting Custom no longer prefills the outgoing family, so the field is ready to type into; the applied font holds until a valid name is entered, and an empty field is not flagged as invalid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four rows all opening with "Used ..." read like a filled-in template and restated their own titles. Each line now just names what it covers, and the prompt row carries the reason people actually asked for it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Matching x-heights exactly overcorrects: real Courier New sits at ~0.42 em against DM Sans' 0.51, so an exact match scaled it 21% up and its capitals and ascenders towered over everything else. The correction is now capped at 6%, and each preview normalizes its own stack rather than a shared default so it shows the size the app will actually render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the font-size-adjust correction with explicit control, mirroring the mobile Appearance sliders: interface, prompt, code, and terminal each get a size in CSS pixels alongside their family. The x-height correction could never satisfy every face - matching exactly blew out Courier New's proportions, capping it left the difference visible - so the size is the user's call now. Ranges are clamped in the schema (the interface size drives the root font size, and with it every rem-based dimension) and each preview renders at the size its surface will use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…grows it The branch, workspace, and environment controls sit in a fixed-width strip, so a larger interface font pushes their labels into each other. The strip is now a query container and the labels hide below a rem-based threshold - rem scales with the interface size, so the controls fall back to their icons exactly when the text would start to overlap, at any font size or window width. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…labels only on overflow The terminal and code views render on a fixed cell grid, so a proportional face like Helvetica Neue cannot line up - their pickers now offer monospace faces only, while the interface and prompt keep the full catalog. The composer strip's labels collapsed at a guessed container width, which hid them even when they fitted. It now measures how far each label is clipped and collapses only once they genuinely stop fitting, remembering the required width so hiding them cannot flip the decision back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A proportional family draws each glyph on its own advance while the cursor and selection sit on the grid's single cell width, so the text drifts left of its cells and the cursor strands to the right - the ragged gaps and misplaced cursor seen with Helvetica Neue. The renderer now measures the requested family and falls back to the default when it is not monospace, so a value stored before the picker was restricted cannot leave the terminal in that state. Unmeasurable environments and absent faces are treated as monospace, leaving the normal fallback chain in charge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The size preference is a ceiling, not an absolute: narrow panes (splits, the side panel) now shrink the rendered size until a classic 80-column grid fits, down to a legibility floor, and widening slides it back up to the preference. The measurement lives in fit(), so pane drags, splits, and window resizes all pass through it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt not boxes The workspace trigger refused to shrink, so a large interface font made it overflow and paint over the branch name. Every control now truncates, which makes overlap impossible; icons-only remains the backstop for when even truncated content cannot fit. The overlap measurement had three faults: it summed flex-stretched boxes (always 'full', so it always wanted to collapse), it counted a Base UI hidden form element as 223px of phantom content, and the compiler-memoized useEffectEvent left observers reading the first render's null element forever. It now sums laid-out content (skipping hidden artifacts and the absolutely-positioned compact labels, which stay measurable at natural width), reads state through a render-synced ref, and applies hysteresis so the boundary cannot flap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Labels slide shut (max-width to zero with a fade) instead of vanishing. Squeezed-shut labels still report their full text through scrollWidth, so the overflow measurement reads the same value in both states and stays transition-invariant - a mid-animation measurement cannot flip the decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l families Three review findings: - The code size preference set --font-size-code but nothing read it. Block code and highlighted chat blocks now take it; inline code stays relative to its sentence so it cannot tower over the surrounding prose. - The custom family field autofocused whenever it mounted, so opening Appearance with a persisted non-catalog family stole the keyboard. Focus now follows the deliberate act of picking Custom from the dropdown. - The terminal silently falls back from a proportional face, while the row still accepted and previewed one. The terminal row now refuses a custom family that is not monospace (flagging the field instead of pretending it took the value), and its preview renders the fallback the grid will actually draw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e family The interactive environment picker rendered a bare SelectValue, so the overflow measurement never saw it and it stayed full-width while its neighbours collapsed. It now carries the same label marker and transition. The Code row accepted any custom family, including proportional faces that break column alignment in code blocks, diffs, and file previews. It requires monospace now, matching Terminal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ted labels Three review findings: - High: track clicks and keyboard steps fire input and change in one task, before any re-render, so the change handler read a stale draft and dropped the commit. The draft now mirrors into a ref written inside the input handler itself, so the commit reads the same turn's value. - The global pre font-size rule beat text-size utilities and inherited sizes on unrelated pre surfaces (terminal previews, approvals). The code size is now scoped to chat markdown code blocks; diffs and file previews already take it through --diffs-font-size. - SelectValue truncates internally, so the outer label marker's scrollWidth matched its clipped box and the workspace/environment labels fell out of the strip's overflow measurement. The text width is now the largest scrollWidth in the label subtree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 16px focused-field floor exists for the touch-browser zoom quirk, which desktop browsers do not have - a narrow desktop window must not silently override a smaller chosen prompt size. Gate the media query on a coarse pointer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What changed
Settings → Appearance gains a Fonts section: four surfaces, each with a family picker and a size slider, a live preview rendered in the resolved family and size, and a per-row reset.
The family pickers are grouped by category (Sans serif / Monospace) and filtered to faces that are actually installed, probed with canvas metrics —
document.fonts.check()reportstruefor absent families, so it cannot filter anything. "Custom…" accepts any name: it edits a local draft that commits 400ms after typing pauses (or on Enter/blur) and only once the name resolves, so the applied font holds while a partial name is typed.Cell-grid surfaces stay monospace. The terminal draws on a fixed cell lattice, so a proportional face renders its text narrower than its own cells and strands the cursor to the right. The Code and Terminal pickers offer monospace faces only, and the renderer independently measures the requested family and falls back to the default when it is not monospace, so a previously stored value cannot leave the terminal in that state.
The terminal size adapts to its canvas. The preference is a ceiling: narrow panes (splits, the side panel) slide the rendered size down until a classic 80-column grid fits, down to an 8px legibility floor, and widening slides it back up. The measurement lives in
fit(), so drags, splits, and window resizes all pass through it.The composer context strip degrades instead of colliding. Its controls truncate, and once even truncated labels cannot fit they animate shut to icons (measured from laid-out content, with hysteresis, restoring as space returns).
Under the hood:
--font-sans/--font-monotheme tokens move out of@theme inlineso Tailwind utilities reference the variables;body,pre/code, and the@pierre/diffsshadow-root hooks route through themValidation
🤖 Generated with Claude Code
Note
Medium Risk
Interface font size scales all rem-based layout globally; terminal font fitting and async hydration re-apply paths add edge cases, but changes are localized to appearance settings and rendering surfaces with validation for monospace terminals.
Overview
Adds Settings → Appearance → Fonts: four contexts (interface, prompt, code, terminal) with family pickers, size sliders, live previews, and restore-defaults integration. Eight new client settings land in contracts (four families, four clamped sizes).
Runtime application:
appearanceFonts.tsnormalizes family names, probes installed faces via canvas (notdocument.fonts.check()), validates monospace for cell-grid surfaces, andFontAppearanceSyncwrites--font-sans,--font-mono,--font-composer, and size tokens on the document root. Interface size drives rootfont-size(rem layout); prompt and code sizes stay in px. CSS moves font tokens to@themeso overrides work; body, composer, chat code, and@pierre/diffshooks route through the variables.Terminal:
ThreadTerminalDrawerappliesfontFamilyTerminal/fontSizeTerminalat create and on live updates; proportional families are rejected; narrow panes auto-shrink rendered size toward an 80-column fit viafittedTerminalFontSize.Composer strip:
BranchToolbarcollapses context labels to icons when space is tight (useLabelsOverflow, resize/font-load aware, hysteresis).UI polish: Glass opacity slider class renamed to
.settings-slider; composer prompt size drops hardcoded Tailwind text sizes in favor of.composer-editor-surface.Reviewed by Cursor Bugbot for commit a6f5f65. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add configurable font families and sizes to Settings → Appearance
appearanceFonts.tswith utilities for font stack normalization, canvas-based font availability detection, monospace validation, and CSS variable application viaapplyAppearanceFontVariables.fontSizeInterface,fontSizePrompt,fontSizeCode,fontSizeTerminal,fontFamilySans,fontFamilyComposer,fontFamilyCode,fontFamilyTerminal) toClientSettingsSchemainpackages/contracts/src/settings.ts.FontAppearanceSynccomponent mounted in the app shell.fontFamilyTerminalandfontSizeTerminalfrom client settings, applies them at creation, and updates live; the terminal surface also dynamically reduces font size to preserve at least 80 columns within available canvas width.BranchToolbargains overflow-aware compact mode that collapses labels to icons under constrained widths, triggered by a newuseLabelsOverflowhook that accounts for font changes..glass-opacity-slider/--glass-slider-*to.settings-slider/--settings-slider-*.Macroscope summarized a6f5f65.